iT邦幫忙

2023 iThome 鐵人賽

DAY 29
0
Software Development

前端? 後端? 摻在一起做成全端就好了系列 第 29

29 使用tauri進行服務器及用戶瑞gRPC授權,以及加OpenAPI(swagger)

  • 分享至 

  • xImage
  •  

網頁版的權限設好了,接下來試著處理tauri app的權限:

gRPC 後端 auth

gRPC的後端是使用tonic套件,不像warp是用鐵道的方法,我們只要在中間穿插要加入的中間件就好,處理上比較麻煩:

首先在後端server調整如下:

// web/src/grpc/mod.rs
use tonic::{Request, Status};
use crate::auth::{CurrentUser, key, verify_jwt};

pub fn grpc_route() -> Router {
    let greeter = MyGreeter::default();
    let tic_tac_toe = TicTacToeGrpcService::default();

    Server::builder()
        .add_service(GreeterServer::new(greeter))
        .add_service(TicTacToeServer::with_interceptor(tic_tac_toe, with_auth))
}

tonic是在新增服務的時候,由原本的new方法,改用另一個with_interceptor的方法,使用with_interceptor的話,除了原本服務的物件,還需要加一個中間件的function,這個function用來處理接到request的時候,進行加料使用,這裡的interceptor只能固定格式,不能像warp一樣,不斷加料使用Extract抽出後再進到下一輪,所以在這無法像warp一樣組出基本的積木塊。實作上面的with_auth插件如下:

fn with_auth(mut req: Request<()>) -> Result<Request<()>, Status> {
    match req.metadata().get("authorization") {
        Some(token) => {
            let jwt = token.to_str().unwrap().to_string();
            let jwt = jwt.replace("Bearer ", "");
            tracing::info!("token: {}", jwt);
            let claims = verify_jwt(key(), jwt)?;
            let permissions = claims.permissions;
            let name = claims.sub;
            let user = CurrentUser::User { name, permissions };
            req.extensions_mut().insert(user);
        },
        _ => {
            req.extensions_mut()
                .insert(CurrentUser::Anonymous);
        },
    }
    Ok(req)
}

這個interceptor中間件,因為前面只有接到request進來,所以也只能對request加料,這邊不同於http request使用的是header,這邊是metadata,我們從request的metadata試著去取得是否有key為authorization的資料,如果有的話,就把它的值作為JWT來解析處理,如果沒有的話,我們就當作是匿名使用者。jwt的解析與rest api中解析的方式一樣,就是把JWT進行驗章後解譯內容。

而這裡如果解析正確,只能繼續把Request往下傳,所以我們額外解析出的CurrentUser物件,就要附加在原本的request上面,這邊使用extensions_mut方法,添加至request的擴充資料,避免影響到原本request的內容,而這邊的extensions是靠類別去取的。我們繼續往下看:

// web/src/grpc/tic_tac_toe.rs
use crate::auth::CurrentUser;

async fn new_game(
        &self, request: Request<EmptyRequest>,
    ) -> Result<Response<GameSet>, Status> {
        let user = request
            .extensions()
            .get::<CurrentUser>()
            .unwrap();
        tracing::info!("user: {:?}", user);
        // ... 略
    }

我們先演示怎麼進行interceptor的處理,暫不實際進行權限檢核,在原本的grpc impl 的 fn 中,我們本來就需要帶入參數 request,而剛剛在interceptor加的料就會帶入到這個request參數中,要使用的話就要用 extensions()方法叫出擴充資料集,再使用get::<泛型T>()取得該類別的資料,如果剛剛在interceptor裡有加入這個類別的話,就會解析出來,否則就會傳回None,因為剛剛即便我們沒傳token我一樣會加入CurrentUser::Anonymous(訪客),所以肯會能解析出來,才用unwrap,如果應用的場景會有可能出現None,就不能用unwrap,不然會造成程式panic中斷。

實測一下,使用前端tauri app呼叫gRPC:
gRPC後端加interceptor後實測

會出現匿名使用者,因為我們還沒幫前端加上request帶token

tauri app 加 login auth jwt

雖然說是前端,不過這裡主要在講的是tauri的部分(後端的前端)。前端的登入(login)我們一樣用http rest就好,從login取得JWT後,帶入我們後續gRPC的request之中,在此之前,我們需要先實作我們的login,先login的api未實作tauri的部分:

先加入儲存JWT到我們的context物件:

// app/src-tauri/src/context.rs
use std::sync::{Arc, RwLock};

#[derive(Clone, Debug)]
pub struct Context {
    // ... 略
    pub token: Arc<RwLock<Option<String>>>,  // JWT token
}

impl Context {
    pub fn load() -> Self {
        // ... 略
        Self {
            // ... 略
            token: Arc::new(RwLock::new(None)),
        }
    }
    // ... 略
    pub fn token(&self) -> Option<String> {  // getter
        self.token.read().unwrap().clone()
    }

因為這個token基本上只會寫入一次(在剛開啟畫面的時候),後面其實都只會讀取而已,所以用RwLock,而因為我們的Context是透過tauri的狀態管理manager注入進去的,為了避免跨不同執行緒的處理會有問題,所以這裡需要用Arc包起來,而我們在最後做一個token的getter方便在handler裡要調用時取得token資料。

再來寫tauri的 login handler:

// app/src-tauri/src/auth.rs
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::context::Context;
use crate::error::ErrorResponse;

#[derive(Clone, Deserialize, Serialize)]
pub struct LoginResponse {        // 要在main裡註冊使用的,所以要pub
    pub access_token: String,     
}

#[derive(Serialize)]
struct LoginRequest {            // 只在這個mod檔不需要 pub
    username: String,
    password: String,
}

因為tauri在這裡要承上啟下,取得web端表單輸入的資料進來,再呼叫http request向後端請求,所以這邊需要實作 Request 和 Response 物件,供輸出入使用。

// app/src-tauri/src/auth.rs
#[tauri::command]
pub async fn login(
    username: String,
    password: String,
    mut ctx: State<'_, Context>
) -> Result<LoginResponse, ErrorResponse> {
    let url = ctx
        .base_url()
        .join("login")
        .unwrap();                            // 網址路徑: /login
    let response = ctx
        .http_client()
        .post(url)                            // 使用 POST
        .json(&LoginRequest {                 // 帶 JSON Body
            username,
            password,
        })
        .send()                               // 發出請求
        .await?;
    let jwt: LoginResponse = response         // 回應結果
        .json()                               // 解析 JSON Body
        .await?;
    let mut token = ctx.token                 // 取得 Context的token
        .write()                              // 取得RwLock的write
        .unwrap();
    *token = Some(jwt.clone().access_token);  // 寫入
    Ok(jwt)                                   // 回傳相同物件
}

以上login handler呼叫後端登入API,取得jwt後,存到tauri的AppState中,並把原Response再回傳給前端Svelte,所以前端的前端一樣可以保留其jwt的紀錄。最後到tauri的main中註冊:

@@ app/src-tauri/src/main.rs @@
+mod auth;
+use auth::login;
 ...
 async fn main() -> Result<(), Box<dyn std::error::Error>> {
 ...
     tauri::Builder::default()
 ...
         .invoke_handler(tauri::generate_handler![
 ...
+            login,
         ])

接下來到前端調整tauri版的登入API:

// app/src/api/auth.ts
import { invoke } from '@tauri-apps/api/tauri';

export const tauriLogin = async (username: string, password: string): Promise<void> => {
  try {
    let r: { access_token: string } = await invoke(
        'login', {username, password});
    const jwt = r.access_token;
    setJwt(jwt);
    goto('/game').then(() => console.log('redirect to /game'));
  } catch (e) {
    cleanJwt();
  }
}

跟restapi版本差不多,只是改成利用invoke去呼叫tauri裡的command。最後註冊api:

@@ app/src/api/auth.ts @@
+import { login, tauriLogin } from './auth';
-import { login } from './auth';

 const tauriApi: Api = {
   ticTacToe: ticTacToeApiTauri,
   ticTacToeOffline: ticTacToeApiTauriOffline,
+  login: tauriLogin,
-  login,
 };

完成了login以取得jwt的部分,接下來要實作gRPC的 client 端。

gRCP client

tonic的gRPC client端一樣使用interceptor的概念,用以攔截request,再依需求進行加料。

// app/src-tauri/src/tic_tac_toe/grpc.rs
use tonic::{Request, metadata::MetadataValue};

#[tauri::command]
pub async fn new_game_grpc(ctx: State<'_, Context>)
    // ... 略
    let channel = ctx.channel();
    let token = ctx.token();
    let mut client = TicTacToeClient::with_interceptor(channel,
        move |mut req: Request<()>| {
            if token.is_some() {
                let jwt = token.clone().unwrap();
                let bearer: MetadataValue<_> = 
                    format!("Bearer {}", jwt)
                    .parse()
                    .unwrap();
                req.metadata_mut()
                    .insert("authorization", bearer);
            }
            Ok(req)
        });
    // ... 略

在建造gRPC客戶端時,一樣把先前的 ::new,改為::with_interceptor,就可以加入要使用的interceptor function,而這裡我們用的是閉包,一樣使用 request的參數,我們在這邊對token進行判斷,如果有值的話,就組成Bearer的格式,再加到metadata裡,這裡和後端的寫法很像,需要呼叫metadata_mut取得可修改的metadata物件,再使用insert加入authorization的資料。

這邊的function我一時卡在rust的各種所有權檢核還抽不出去,未來如果找到抽出寫比較好的寫法再作更新。

實測一下:

後端接到gRPC request解析出來的CurrentUser

可以看到當前端呼叫我們剛剛的gRPC 方法時,後端有正確接到jwt並解譯出Current User。

OpenAPI(swagger)

有時候要進行API的測試總是要開UI有點不方便,大家一般會使用swagger之類的工具,只要後端跑起來之後,即便沒有前端,有時候也可以方便調試一下,而rust裡面OpenAPI使用的套件是utoipa。這個套件目前支援大部分的rust web框架,我們就來實作看看。

我們先加入套件:

@@ Cargo.toml @@
[workspace.dependencies]
+utoipa = { version = "4.0" }
+utoipa-rapidoc = { version = "1.0" }
+utoipa-swagger-ui = { version = "4.0" }

@@ web/Cargo.toml @@
[dependencies]
+utoipa = { workspace = true }
+utoipa-rapidoc = { workspace = true }
+utoipa-swagger-ui = { workspace = true }

@@ core/Cargo.toml @@
[dependencies]
+utoipa = { worksapce = true }

utoipa套件在解析Entity或Dto時,有幫我們做好一個巨集ToSchema,我們只要在我們的Struct結構體或Enum枚舉裡加入ToSchema即可:

@@ core/src/tic_tac_toe.rs @@
+use utoipa::ToSchema;
...
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
-#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub enum Symbol {
     O,
     X,
 }

+#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
 pub struct Game {
    pub cells: [Option<Symbol>; 9],
@@ web/src/error.rs @@
+use utoipa::ToSchema;
...
+#[derive(serde::Serialize, ToSchema)]
-#[derive(serde::Serialize)]
+pub struct AppErrorMessage {
-struct AppErrorMessage {

接著在handler上面寫api文件的內容,因為rust不像C#有reflection的功能,所以無法自己掃描有哪些controller,自動產生API,變成我們要自己多寫一些OpenAPI的內容。我們先從controller開始寫:

// web/src/tic_tac_toe.rs
use my_core::tic_tac_toe::Game;

/// GET /tic_tac_toe/:id
#[utoipa::path(
    get,
    path = "/tic_tac_toe/{id}",
    params(
        ("id" = u32, description = "遊戲ID")
    ),
    responses(
        (status = 200, description = "正確取得遊戲結果", body = Game),
        (status = 404, description = "找不到資料", body = AppErrorMessage),
        (status = 500, description = "意外的錯誤", body = AppErrorMessage),
    )
)]
pub fn games_get(
    service: impl TicTacToeService
) -> impl Filter<Extract=(impl warp::Reply, ), Error=Rejection> + Clone {
    warp::path!("tic_tac_toe" / usize)
        .and(warp::get())
        .and(warp::any().map(move || service.clone()))
        .and_then(handle_games_get)
}

我們在games_get的api上面寫utoipa::pathattribute巨集,內容就是我們這個api的內容,比如這個request是GET,路徑path 是"/tic_tac_toe/{id}",路由參數使用{ }包起來,而在參數中可以寫明參數id的類別是u32,產生的swagger就可以讓我們填入,description可以針對該欄位進行描述,而回應的部分,可以依不同回應情境編寫其回碼的狀態碼,說明,以及回傳的body物件長相,我們剛剛幫Game和AppErrorMessage加了ToSchema的巨集,所以這裡body寫的物件就會參照到剛剛toSchema產出的資料,如果沒寫的話會因為對應不到而報錯。

我們再往下看不同的request方法範例:

/// POST /tic_tac_toe/
#[utoipa::path(
    post,
    path = "/tic_tac_toe",
    responses(
        (status = 200, description = "正確開啟遊戲新局", body = Game),
        (status = 500, description = "意外的錯誤", body = AppErrorMessage),
    ),
)]
pub fn games_create(
/// PUT /tic_tac_toe/:id/:num
#[utoipa::path(
    put,
    path = "/tic_tac_toe/{id}/{step}",
    params(
        ("id" = u32, description = "遊戲ID"),
        ("step" = u32, description = "九宮格格號")
    ),
    responses(
        (status = 200, description = "執行成功", body = Game),
        (status = 400, description = "違反遊戲規則", body = AppErrorMessage),
        (status = 404, description = "找不到資料", body = AppErrorMessage),
        (status = 500, description = "意外的錯誤", body = AppErrorMessage),
    )
)]
pub fn games_play(
/// DELETE /tic_tac_toe/:id
#[utoipa::path(
    delete,
    path = "/tic_tac_toe/{id}",
    params(
     ("id" = u32, description = "遊戲ID")
    ),
    responses(
        (status = 204, description = "正確刪除"),
        (status = 404, description = "找不到資料", body = AppErrorMessage),
        (status = 500, description = "意外的錯誤", body = AppErrorMessage),
    )
)]
pub fn games_delete(

接著是重頭戲 OpenAPI 主文的撰寫:

加open_api mod:

@@ web/src/lib.rs @@
+pub mod open_api;

要先造一個ApiDoc結構體,放置產生的OpenAPI文件:

// web/src/open_api.rs
use std::sync::Arc;
use utoipa::{
    Modify, OpenApi, 
    openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}
};
use utoipa_swagger_ui::Config;
use utoipa_rapidoc::RapiDoc;
use warp::{
    Filter, Rejection, Reply,
    path::{FullPath, Tail},
    http::{Response, StatusCode, Uri}
};

#[derive(OpenApi)]
#[openapi(
    paths(
        crate::tic_tac_toe::games_get,
        crate::tic_tac_toe::games_create,
        crate::tic_tac_toe::games_play,
        crate::tic_tac_toe::games_delete,
    ),
    components(
        schemas(
            my_core::tic_tac_toe::Game,
            my_core::tic_tac_toe::Symbol,
            crate::error::AppErrorMessage,
        )
    ),
    modifiers(& SecurityAddon),
    tags(
        (name = "game", description = "TicTacToe Game API"),
    ),
)]
pub struct ApiDoc;

這裡可以看到我們在API文件中加入了path路徑的設定,就是參照到我們剛剛所寫的那幾個api handler的方法,這些巨集就會去取得剛剛在api裡所寫的說明等內容。而components需要在這邊註冊剛剛加ToSchema的物件,才會生效。modifiers是可以加入授權輔助,輸入api key 或 token讓 OpenAPI幫我們代入。

pub struct SecurityAddon;

impl Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered.
        components.add_security_scheme(
            "Authorization",
            SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
        )
    }
}

上面是驗證的項目可以依需要使用,比如API key或是我們剛剛使用的JWT token,在使用OpenAPI呼叫時可以自動幫我們帶入授權的Header,我這邊是設定Authorization,有些人可能用API-KEY會使用api_keyX-API-KEY等,只要後端設定好跟前端送的一致就可以。

設定swagger服務:

async fn serve_swagger(
    full_path: FullPath,
    tail: Tail,
    config: Arc<Config<'static>>,
) -> Result<Box<dyn Reply + 'static>, Rejection> {
    if full_path.as_str() == "/swagger-ui" {
        return Ok(Box::new(warp::redirect::found(Uri::from_static(
            "/swagger-ui/",
        ))));
    }

    let path = tail.as_str();
    match utoipa_swagger_ui::serve(path, config) {
        Ok(file) => {
            if let Some(file) = file {
                Ok(Box::new(
                    Response::builder()
                        .header("Content-Type", file.content_type)
                        .body(file.bytes),
                ))
            } else {
                Ok(Box::new(StatusCode::NOT_FOUND))
            }
        }
        Err(error) => Ok(Box::new(
            Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(error.to_string()),
        )),
    }
}

加handler

pub fn api_doc_handler() -> impl Filter<Extract=impl Reply, Error=Rejection> + Clone {
    let api_doc = warp::path("api-doc.json")
        .and(warp::get())
        .map(|| warp::reply::json(&ApiDoc::openapi()));

    let rapidoc_handler = warp::path("rapidoc")
        .and(warp::get())
        .map(|| warp::reply::html(RapiDoc::new("/api-doc.json").to_html()));

    let config = Arc::new(Config::from("/api-doc.json"));
    let swagger_ui = warp::path("swagger-ui")
        .and(warp::get())
        .and(warp::path::full())
        .and(warp::path::tail())
        .and(warp::any().map(move || config.clone()))
        .and_then(serve_swagger);

    api_doc.or(rapidoc_handler).or(swagger_ui)
}

最後在main裡設定剛剛的handler:

// web/src/routers.rs
use crate::open_api::api_doc_handler;

pub fn all_routers(ctx: AppContext)
    // ... 略
    api_doc_handler()
        .or(hello)
    // ... 略

最後實測一下:

開啟swagger的頁面
完成swagger介面

看一下展開PUT API內容
swagger內容

實測一下執行的結果
實測swagger呼叫結果

除了swagger以外,我們剛剛還加了另外一個不同的頁面實作RapiDoc,也是吃同樣的api文件,介面風格不太一樣,可以給大家多一種選擇

rapidoc畫面

rapidoc實測結果

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
28 前端授權與驗證
下一篇
30 一段旅程的結束是另一段旅程的開始
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言